Datum & redeemer flow
Validators in Pebble communicate with their off-chain partners through two channels: the datum (per-UTxO state) and the redeemer (per-action intent). Understanding which goes where, and when, is the difference between code that "compiles" and code that actually validates the transactions you intend.
The data path
┌──────────────┐ ┌──────────────┐
│ Off-chain │ ── places UTxO with datum ──→ │ Ledger │
│ (TxBuilder) │ │ (chain state)│
└──────────────┘ └──────────────┘
│
┌──────────────┐ ── spends UTxO + redeemer ──→ │
│ Off-chain │ ──────────────────────────────────────────┘
│ (TxBuilder) │ ↓
└──────────────┘ ┌──────────────────────┐
│ Validator script │
│ reads: state, │
│ redeemer, │
│ context.tx │
└──────────────────────┘
Datum is attached to a UTxO when it is created. Redeemer is attached to an input when that UTxO is spent. The script sees both, plus the transaction itself, and either returns void (accept) or fails (reject).
Datum
| Aspect | Where it lives | Set by | When |
|---|---|---|---|
| Datum | A TxOut | The transaction that created the output | Once, at output-creation time |
| Inline vs hash | Either the full datum or its hash | The output's creator | At output-creation time |
| Pebble surface | context.optionalDatum: Optional<data> or, for stateful contracts, context.state |
When you write a state in Pebble, the compiler synthesizes a sum type with one constructor per state declaration. The datum on a UTxO at the contract's address is a CBOR-encoded instance of that sum. The compiler also bakes the decode logic into the validator, so context.state is already typed by the time you see it.
If you don't use state, you can read the raw datum:
spend handler() {
const { optionalDatum } = context;
match optionalDatum {
Some{ value: rawData }: useIt(rawData),
None{}: fail "missing datum"
}
}
Redeemer
| Aspect | Where it lives | Set by | When |
|---|---|---|---|
| Redeemer | An entry in tx.witnesses.redeemers | The transaction submitter | When the action is attempted |
| Per-purpose | Each spend / mint / certify / withdraw / propose / vote has its own redeemer | Submitter | Same |
| Pebble surface | The method's parameters |
The redeemer that picks which method to dispatch is the redeemer's constructor index. The redeemer payload is the destructured fields. If OrderBook declares two spend methods:
contract OrderBook {
state Simple { /* ... */ spend fill(inputIdx: int, outputIdx: int) { /* ... */ } }
spend cancel() { /* ... */ }
}
then off-chain you pick:
DataConstr(0, [DataI(i), DataI(o)])→ dispatches tofill(i, o)DataConstr(1, [])→ dispatches tocancel()
The constructor index is the method's declaration order for that script purpose.
Building these in buildooor
Given a Pebble method like
spend handler(payload: MyRedeemer) { /* ... */ }
and an off-chain shape
import { DataConstr, DataI, DataB } from "@harmoniclabs/buildooor";
the rules are:
- Top-level: every redeemer is a
Constr(methodIndex, [...]). int:DataI(value)wherevalueisbigint.bytes:DataB(uint8array).string: encode as UTF-8 bytes, thenDataB(...).bool:DataConstr(0, [])forfalse,DataConstr(1, [])fortrue(UPLC convention).Optional<T>:DataConstr(0, [DataT])forSome{ value },DataConstr(1, [])forNone{}.List<T>/Array<T>:DataList([t0, t1, ...])usingDataListfrom buildooor.LinearMap<K, V>:DataMap([[k0, v0], [k1, v1], ...])usingDataMap.struct Foo { a, b, c }:DataConstr(0, [a, b, c])— one constructor, fields in declaration order.- A
stateconstructor:DataConstr(index, [field0, field1, ...])whereindexis the state's position among the contract'sstatedeclarations.
Failing to match these is the most common cause of "the script ran and rejected for no obvious reason". When in doubt, log the buildooor-computed CBOR and compare to what the validator pattern-matches.
A worked round-trip
Pebble side:
contract Counter
{
state Running {
count: int
owner: PubKeyHash
spend increment(by: int)
{
const { tx, state: { count, owner } } = context;
assert tx.signatories.includes(owner);
assert by > 0;
// (a fuller example would also assert that the continuing output
// carries the updated state datum and the same value)
}
}
}
Off-chain side:
import { DataConstr, DataI, DataB } from "@harmoniclabs/buildooor";
// Building the *output* datum for a fresh counter:
const initialState = new DataConstr(0, [
new DataI(0n), // count
new DataB(ownerPubKeyHash.toBuffer()), // owner
]);
// Building the *spend* redeemer to increment by 5:
const incRedeemer = new DataConstr(0, [
new DataI(5n), // by
]);
When the transaction is submitted, the validator receives context.state == Running{ count: 0, owner: <pkh> } and the redeemer decodes as (by: 5). The spend increment body runs.
Common confusions
- "The datum is the redeemer" — no. Datum is on the UTxO, set at creation. Redeemer is on the input, set at spend. They serve different purposes and live in different parts of the transaction.
- "The redeemer is just the method arguments" — yes, but with a constructor tag selecting which method. Don't forget the
Constr(index, [...])wrapper. - "
failin a redeemer aborts the transaction" — the redeemer is data, it doesn't run anything. The validator runs and may abort. "Sending a fail redeemer" isn't a thing. - "I can read the datum of another input" — yes: every
TxInintx.inputscarries its resolvedTxOut, including the datum. Patterns like "verify the order at input N still holds" readtx.inputs[N].resolved.datum.
See also
- Validators 101 — how
contextcarries everything - State — how
statesynthesizes the datum sum type - Contract Statements —
spend/mint/ ... method-kind dispatch - Simple Order Book DEX — datum and redeemer in a non-trivial example